개수 필드 + 포인터의 포인터 — 동적 문자열 배열 만들고 해제하기

NOTE

몇 개가 들어올지 컴파일 타임에 알 수 없는 문자열 목록을 구조체에 담을 때 쓰는 “개수 필드 + char **” 조합과, 이중으로 할당된 메모리를 순서대로 해제하는 방법을 정리한다.

0. 왜 필요한가

설정 파일 항목이나 파싱 결과처럼 “문자열이 몇 개 들어올지 미리 알 수 없는” 목록을 구조체 필드로 표현해야 할 때가 있다. 배열 크기를 컴파일 타임에 못 박을 수 없으므로, 실무에서는 거의 항상 개수를 나타내는 필드와 포인터의 포인터를 짝지어 쓴다.

typedef struct {
    int    count;   /* list에 실제로 담긴 문자열 개수 */
    char **list;    /* list[0], list[1], ..., list[count-1] 각각이 문자열 하나 */
} str_list_t;

1. char **가 가리키는 것 — 포인터의 포인터 읽는 법

char *가 문자열 하나(문자들이 늘어선 메모리의 시작 주소)를 가리킨다면, char **는 그런 포인터들이 늘어선 배열의 시작 주소를 가리킨다.

char **list;
/* list        : char* 들이 늘어선 배열을 가리키는 포인터 */
/* list[i]     : i번째 문자열의 시작 주소 (타입은 char*) */
/* list[i][j]  : i번째 문자열의 j번째 문자 (타입은 char) */

한 단계씩 벗겨낸다고 생각하면 된다 — list에 [i]를 붙이면 포인터 하나(char *)가 나오고, 거기에 다시 [j]를 붙이면 문자 하나(char)가 나온다.

TIP

왜 정수(int) 가변 배열은 int *인데, 문자열은 char **인가? C언어에서 가변 동적 배열의 포인터 차수는 항상 **원소 타입(T) ***입니다.

  • int 배열: 원소 1개가 int이므로, 동적 배열은 **int * (단일 포인터)**입니다. malloc 1번, free 1번으로 끝나며 이중 포인터(int **)를 쓸 이유가 없습니다.
  • string 배열: C언어에는 string 기본형이 없고 원소 1개가 이미 char *입니다. 따라서 동적 배열은 (char *) * = **char ** (이중 포인터)**가 됩니다. “포인터들의 배열”과 “각 문자열 메모리”를 2단계로 할당/해제해야 하는 이유가 바로 여기에 있습니다.

2. 2단계 할당 — “포인터를 담을 배열”과 “문자열 자체”는 별개의 메모리다

#include <stdlib.h>
#include <string.h>
 
str_list_t *make_str_list(int count)
{
    str_list_t *sl;
    int i;
 
    sl = malloc(sizeof(str_list_t));
    if (sl == NULL) {
        return NULL;
    }
 
    sl->list = malloc(sizeof(char *) * count);   /* 1단계: 포인터 count개를 담을 배열 */
    if (sl->list == NULL) {
        free(sl);
        return NULL;
    }
 
    sl->count = count;
    for (i = 0; i < count; i++) {
        sl->list[i] = NULL;   /* 아직 채우기 전 — 실패 시 안전하게 정리하기 위한 초기화 */
    }
 
    return sl;
}
 
int str_list_set(str_list_t *sl, int idx, const char *value)
{
    if (idx < 0 || idx >= sl->count) {
        return -1;
    }
    sl->list[idx] = strdup(value);   /* 2단계: 문자열 자체를 담을 메모리 */
    return (sl->list[idx] != NULL) ? 0 : -1;
}

sl->list(포인터들을 담는 배열)와 sl->list[i](각 칸이 실제로 가리키는 문자열)는 서로 다른 malloc 호출로 만들어진, 완전히 별개의 메모리 블록이다. 이 둘을 하나로 착각하는 게 이 패턴에서 나는 사고의 대부분이다.

3. 해제는 반드시 역순 — 원소 먼저, 배열은 그다음

void free_str_list(str_list_t *sl)
{
    int i;
 
    if (sl == NULL) {
        return;
    }
 
    for (i = 0; i < sl->count; i++) {
        free(sl->list[i]);   /* 각 문자열을 먼저 해제 */
    }
    free(sl->list);          /* 포인터 배열 자체를 그다음 해제 */
    free(sl);                /* 마지막으로 구조체 자체 해제 */
}

순서를 바꿔서 free(sl->list)를 먼저 부르면, 그 배열 안에 담겨 있던 각 문자열의 주소를 더 이상 알아낼 방법이 없어진다 — 그 문자열들은 영원히 해제할 수 없는 메모리 누수로 남는다. 만든 순서(구조체 → 배열 → 각 문자열)의 정확히 역순(각 문자열 → 배열 → 구조체)으로 해제해야 한다는 원칙은 실무 C 코드 관례와 UB 함정 정리의 A14(생성 역순 정리)와 정확히 같은 이유다.

4. 자주 하는 실수

  • sizeof(char *)를 sizeof(char)로 잘못 쓰기 — malloc(sizeof(char) * count)는 문자 count개(포인터가 아니라 겨우 count바이트)만큼만 할당한다. 포인터 배열이 필요한 자리에 이렇게 쓰면 할당한 크기보다 훨씬 큰 메모리를 쓴 것처럼 접근하게 되어 배열 경계를 한참 벗어난다.
  • 원소를 채우기 전에 초기화를 안 해서, 일부만 채워진 상태의 배열을 잘못 정리하기 — 2번 예제처럼 배열을 만들자마자 각 칸을 NULL로 채워두면, 중간에 실패해서 일부 칸만 strdup된 상태라도 정리 루프가 NULL인 칸은 안전하게 건너뛴다(free(NULL)은 표준상 아무 일도 안 하는 안전한 호출이다). 이 초기화를 빼먹으면 아직 안 채워진 칸에 남아있는 쓰레기 값을 문자열 포인터인 줄 알고 free하다가 크래시가 난다.
  • list[i](문자열 하나)와 list(배열 전체)를 헷갈려서 원소별 해제를 빼먹기 — free(sl->list)만 부르고 3번의 반복 해제 루프를 생략하면, 배열 껍데기만 없어지고 그 안에 있던 문자열들은 그대로 누수된다.
  • 배열 크기를 늘릴 때 realloc만 하고 새로 늘어난 칸을 초기화하지 않기 — sl->list = realloc(sl->list, sizeof(char *) * new_count); 이후 기존 칸들의 내용은 유지되지만, 새로 늘어난 칸들은 쓰레기 값을 담고 있다. 늘어난 구간을 NULL로 채우는 코드를 꼭 같이 넣어야, 나중에 그 칸을 실수로 free하거나 참조했을 때 안전하다.

5. 단일 포인터 버전 — 원소가 이미 구조체·기본형 값 하나일 때 (T * + count)

원소 자체가 포인터(char *)라서 char **가 필요했던 것과 달리, 원소가 구조체나 int처럼 그 자체로 완전한 값이면 포인터 한 단계로 끝난다. 1절 TIP에서 언급한 int * 케이스의 일반형이다.

typedef struct {
    struct_temp *item;   /* item[0]..item[count-1] 각각이 struct_temp 값 하나 (포인터 아님) */
    uint32_t     count;
} item_list_t;
  • item_list_t가 스택 변수면 malloc은 item 배열 하나만 하면 된다.
  • item_list_t 자체도 힙에 두려면 malloc이 두 번(컨테이너, 배열)이고, free는 그 역순(배열 → 컨테이너)이다 — 원칙은 3절과 동일하다.

5-1. sizeof(*item) — 포인터 크기가 아니라 가리키는 대상의 크기

item_list_t list = {0};
list.count = n;
list.item  = calloc(list.count, sizeof(*list.item));

.(멤버 접근)이 단항 *(역참조)보다 우선순위가 높아서 *list.item은 *(list.item)으로 해석된다. list.item의 타입이 struct_temp *이므로 *(list.item)은 struct_temp 값 하나 — 즉 sizeof(*list.item) == sizeof(struct_temp)다. 여기서 sizeof(list.item)(포인터 자체 크기, 8바이트 등)을 쓰면 4절의 sizeof(char *) vs sizeof(char) 실수와 같은 계열의 버그가 된다. sizeof(*ptr)로 쓰는 습관을 들이면 item의 타입이 나중에 바뀌어도 이 줄을 고칠 필요가 없다.

5-2. malloc(count * sizeof(*item)) 대신 calloc(count, sizeof(*item))

count가 외부 입력(파일, 네트워크, 프로토콜 필드)에서 오면 count * sizeof(*item)은 size_t 곱셈 오버플로우가 날 수 있다. 오버플로우가 나면 malloc은 실제 필요한 크기보다 훨씬 작은 버퍼를 반환하고, 이후 item[i] 접근이 곧바로 힙 오버플로우로 이어진다. calloc(count, size)는 표준(C11 7.22.3.2)상 이 곱셈을 내부에서 오버플로우 검사하고, 넘치면 할당 없이 NULL을 반환하도록 규정돼 있다 — 오버플로우 검사를 직접 짜지 않아도 되는 가장 값싼 방어선이다. 직접 검사가 필요한 경우(덧셈·뺄셈이 섞이는 등)는 SD17. 정수 오버플로우 참고.

list.item = calloc(list.count, sizeof(*list.item));
if (list.item == NULL) {
    /* count==0 / 오버플로우 / 진짜 메모리 부족 — 셋 다 NULL로 들어온다 */
    return RET_NO_MEM;
}

6. 이중 포인터는 for문 하나, 3중 포인터는 피한다

6-1. 이중 포인터의 반복 횟수

char **의 2단계 할당은 호출 한 번 + for문 하나다. 2중 for문이 아니다.

단계횟수크기
1단계 (포인터 배열)calloc 1번sizeof(*pp) × n
2단계 (각 원소)for문 1개, malloc n번칸마다 필요한 크기 (문자열이면 strlen + 1)

2단계의 크기는 원소마다 달라도 된다. 해제도 for문 하나로 원소를 지운 뒤 배열을 지우는 역순이다(3절). 2중 for문이 필요한 쪽은 할당이 아니라 내용을 훑을 때(모든 문자열의 모든 글자 순회, 2차원 데이터 채우기)다.

6-2. 3중 포인터는 할당·해제가 곱으로 늘어난다

T ***ppp로 a × b 구조를 만들면 반복이 중첩된다.

T ***ppp = malloc(a * sizeof(*ppp));                  /* 1단계: 1번 */
for (int i = 0; i < a; i++) {
    ppp[i] = malloc(b * sizeof(**ppp));               /* 2단계: a번 */
    for (int j = 0; j < b; j++)
        ppp[i][j] = malloc(sizeof(T));                /* 3단계: a × b번 */
}

a = 100, b = 100이면 malloc이 10,101번이다. 해제도 같은 중첩 반복이다.

느려지고 위험해지는 이유는 for문의 개수보다 할당 횟수와 메모리 배치 쪽이다.

이유설명
malloc 호출 횟수호출마다 비용이 들고 힙 락 경합이 생길 수 있다
메모리가 흩어짐작은 조각이 힙 여기저기에 생겨 캐시 효율이 떨어진다
포인터를 여러 번 따라감ppp[i][j]는 원소에 닿기까지 메모리를 두 번 읽는다 (pointer chasing)
힙 단편화작은 블록이 많이 생겨 힙이 조각난다
해제 실수중첩 반복이라 하나라도 빠뜨리면 누수고, 중간에 실패했을 때 되돌리기도 복잡하다

6-3. 대안

방법설명
평평한 1차원 배열malloc(a * b * sizeof(T)) 한 번, 접근은 p[i * b + j]. 곱셈 오버플로우는 calloc(a * b, ...)나 사전 검사로 막는다
구조체 배열 + 개수 필드의미 있는 묶음을 구조체로 만들고 struct S *items; int count;로 쓴다. 5절의 단일 포인터 버전이 이 방식이다
배열 포인터열 수가 컴파일 타임에 정해지면 T (*p)[b] = malloc(a * sizeof(*p)); 한 번으로 2차원처럼 접근한다
큰 블록 하나에 포인터만 매달기문자열 전체를 담을 블록 하나와 포인터 배열 하나만 할당하고, 각 포인터가 블록 안의 위치를 가리키게 한다. 할당·해제가 두 번으로 끝나지만 원소를 따로 free하거나 realloc할 수 없다
char  *block = malloc(total_bytes);             /* 모든 문자열을 담을 큰 블록 */
char **pp    = malloc(n * sizeof(*pp));         /* 포인터 배열 */
size_t off = 0;
for (int i = 0; i < n; i++) { pp[i] = block + off; off += len[i] + 1; }
 
free(pp);
free(block);

정리하면 이중 포인터(char **)는 항목마다 길이가 제각각인 문자열 목록처럼 필요한 곳에서 쓰고, 3중 이상이 필요해 보이면 구조체나 평면 배열로 바꿀 수 있는지 먼저 본다. 어느 계층이 무엇을 할당하고 해제하는지는 동적 할당 소유권의 “malloc 이 필요한 경우의 수”를 참고한다.

관련 문서